用 MCP 搭一套 Agent 工具链:踩过的坑和最终架构
从最小可用的工具调用循环开始,讲清工具描述怎么写、上下文怎么管、多步任务为什么会跑飞,以及我最后收敛到的架构。
MCP(Model Context Protocol)解决的是一个很实际的问题:别让每个 Agent 框架都自己实现一遍工具接入。
在它出现之前,我接一个「读数据库」的能力,要分别为 LangChain、自研框架、Claude 客户端各写一遍适配。有了 MCP,写一个 server,所有支持它的客户端都能用。
但真正把 Agent 跑稳,MCP 只是其中一块。这篇记录我踩过的坑。
最小可用的工具调用循环
抛开框架,Agent 的本质就是一个 while 循环:
def run_agent(user_input, tools, max_steps=10):
messages = [{"role": "user", "content": user_input}]
for step in range(max_steps):
response = llm.chat(messages, tools=tools)
# 没有工具调用 → 任务结束
if not response.tool_calls:
return response.content
messages.append(response.message)
for call in response.tool_calls:
try:
result = execute(call.name, call.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": str(result)[:4000], # 一定要截断
})
except Exception as e:
# 关键:把错误也喂回去,而不是直接抛出
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": f"工具执行失败:{e}",
})
return "达到最大步数限制,任务未完成"
这三十行里有两个地方最容易写错:
错误必须喂回模型。 直接抛异常终止,模型就没机会换一种方式重试。把 "工具执行失败:文件不存在" 作为工具结果返回,模型通常会自己改用正确的路径。
工具结果必须截断。 一次返回 50 KB 的 JSON,上下文直接爆掉,而且这部分内容 95% 是噪音。
工具描述怎么写,决定了 Agent 稳不稳
这是我投入产出比最高的一处改动。工具描述不是写给人看的文档,是写给模型看的接口说明。
对比一下:
// 差:模型不知道什么时候该用、参数该给什么
{
"name": "query_db",
"description": "查询数据库",
"parameters": { "sql": { "type": "string" } }
}
// 好:明确了用途边界、何时不该用、参数格式和限制
{
"name": "query_db",
"description": "对只读的 PostgreSQL 数据库执行 SELECT 查询。仅用于读取数据,写入操作请使用 write_db。单次查询超时 10 秒,返回最多 100 行,超过会被截断。",
"parameters": {
"sql": {
"type": "string",
"description": "标准 PostgreSQL SELECT 语句。不要加分号,不要使用 WITH RECURSIVE。"
}
}
}
三件事必须写清楚:
- 什么时候不该用 —— 这是收益最大的一条。模型幻觉调用工具,多半是因为描述里没有边界
- 失败会怎样 —— 超时多久、截断到多少行,让模型对返回结果有预期
- 参数字面要求 —— 「不要加分号」这种细节,能消掉一大批解析错误
上下文管理:Agent 会自己把自己撑死
多步任务里,每轮的工具返回都会进上下文。跑 10 步之后,历史里塞满了原始数据,模型开始忘记最初的目标。
我试过三种办法:
| 办法 | 效果 | 代价 |
|---|---|---|
| 只保留最近 N 轮 | 简单有效 | 丢失早期关键信息 |
| 每轮结束生成摘要 | 效果好 | 多一次模型调用,延迟增加 |
| 把中间结果存外部,上下文只留引用 | 最省上下文 | 实现复杂 |
我最后用的是混合方案:工具返回先在代码里做结构化压缩(只留关键字段),累积到一定量之后再让模型生成一次摘要。
def compress_tool_result(name, raw):
"""在进入上下文之前先压缩,而不是指望模型自己忽略噪音"""
if name == "query_db":
rows = raw[:100]
return {
"columns": raw.columns,
"row_count": len(raw),
"preview": rows,
"note": "已截断,完整结果已保存到 /tmp/result.csv" if len(raw) > 100 else None,
}
return str(raw)[:4000]
把完整结果落盘、上下文里只留路径和预览,是我觉得最实用的一招。需要细节时模型可以再调一次工具去读文件。
多步任务为什么会跑飞
三个典型失败模式,和对策:
任务漂移。 模型在第 6 步开始做一件和原始目标无关的事。对策是在每轮循环开头注入一句系统提醒:当前目标:{原始请求}。已完成:{步骤摘要}。
死循环。 反复用同样的参数调同一个工具。对策是记录调用指纹(工具名 + 参数哈希),重复超过 2 次就直接返回错误提示模型换方案。
过早收尾。 才执行了一步就声称任务完成。对策是把「完成条件」显式写进提示词,并要求模型在结束前自检一遍。
# 死循环检测,很简单但极其有效
seen = {}
fingerprint = f"{call.name}:{hash(json.dumps(call.arguments, sort_keys=True))}"
seen[fingerprint] = seen.get(fingerprint, 0) + 1
if seen[fingerprint] > 2:
return "你已经用相同参数调用过这个工具两次,请换一种方式或给出结论。"
我最终收敛的架构
用户请求
↓
规划层(拆解成步骤,可选,简单任务跳过)
↓
执行循环 ←──────────────┐
├─ 选工具 │
├─ 参数校验 │
├─ 执行(带超时) │
├─ 结果压缩 │
├─ 死循环检测 │
└─ 状态更新 ─────────┘
↓
完成条件校验 → 通过 → 返回结果
↓ 不通过
带反馈重新执行
核心设计取舍:把确定性的事情放在代码里,把需要判断的事情交给模型。
超时、重试、去重、截断、状态机 —— 这些都是代码该干的。模型负责的是「下一步做什么」和「拿到结果后怎么解读」。
我见过太多 Agent 项目把重试逻辑也塞进提示词让模型自己决定,结果就是不稳定、还贵。
小结
MCP 让工具接入标准化了,但 Agent 稳不稳,主要看这几件事:工具描述写得够不够清楚、上下文有没有主动管理、失败路径有没有设计。
示例 server、主循环代码和一批工具描述范例都在下载区,可以直接拿去改。
资源下载
2 个入口- 百度网盘提示词模板 + 工具描述范例 + 调试日志样本提取码mcp7
链接若失效,欢迎发邮件告诉我,我会尽快重新上传。